fix(plugin-auth): 限流计数器惰性解析 kernel cache —— 误报的告警,与它掩盖的共享限流功能洞 - #4788
Merged
Conversation
…nit (#4772) `AuthPlugin.init()` probed `getServiceAsync('cache')` and froze the answer for the life of the process. It runs BEFORE `CacheServicePlugin` registers the service (21ms earlier in a showcase cold start), so the probe resolved `undefined` in deployments that have a cache configured — and the warning it printed told the operator to provision Redis for a problem they did not have. The misdiagnosis was the visible half. The real defect: better-auth is built lazily but from the config captured at init, so the "no cache" conclusion was permanent. Rate-limit counters never reached the shared store even after it came up, meaning a multi-node deployment's limits were never enforced globally (ADR-0069 D2 declared a capability the runtime did not deliver). `createLazyCacheRateLimitStorage()` implements better-auth's `rateLimit.customStorage` and resolves the `cache` service when a counter is actually consumed — strictly after `kernel:ready`, therefore independent of plugin start order. The warning is kept but now fires only when a counter genuinely has nowhere shared to count, once per process; without a cache the limit is still enforced, in-process (degraded, never disabled). Deliberately `customStorage`, not `secondaryStorage`: the latter also moves the session of record into the cache (`createSession` skips the `sys_session` row, `findSession` answers from the snapshot without reading the database), which silently disables the ADR-0069 D4 session controls — idle timeout, absolute max and concurrent cap all revoke by writing that row. The cache is therefore no longer auto-bound as `secondaryStorage`; `cacheSecondaryStorage` is exported for a host that opts into that trade knowingly. Where the session of record belongs is #4785. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 3, 2026
os-zhuang
marked this pull request as ready for review
August 3, 2026 06:44
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Aug 3, 2026
This was referenced Aug 3, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 3, 2026
…store (objectstack-ai#4790) (objectstack-ai#4806) objectstack-ai#2780's per-number OTP budget (60s cooldown + 5/hour) was shared across nodes ONLY when a host supplied better-auth's `secondaryStorage`. Nothing in the standard `serve` composition supplies one — and since objectstack-ai#4788, AuthPlugin deliberately does not derive it from the kernel cache either — so the budget was counted per process: an N-node deployment granted one phone number N cooldowns and N hourly caps, in paid SMS, with no signal that the declared limit was not the enforced one (ADR-0049). Same defect class as objectstack-ai#4772's rate-limit counters, and now the same cure rather than a second implementation of it. The lazy-resolution half of `createLazyCacheRateLimitStorage` is extracted as `createLazyCounterStore()`: resolve the `cache` service when a counter is CONSUMED (strictly after `kernel:ready`, so plugin start order decides nothing), memoise the handle, fall back to the bounded in-process store when there is genuinely no cache — and say which of the two happened, once. The OTP guard reaches it through the new `AuthManagerOptions.sharedCounterStore`, filled by AuthPlugin from the same `resolveCache` closure the rate-limit counters use. Deliberately NOT `secondaryStorage` (objectstack-ai#4785): that also relocates the session of record into the cache and silently disables the ADR-0069 D4 session controls. A host-supplied `secondaryStorage` still wins for this budget, unchanged. The cooldown / rolling-hour semantics are untouched — only where the timestamps live changed. A fixed-window counter cannot express "N seconds since the last send", and converting the hourly cap to one would admit a 2× burst across the window boundary: trading one multiplication for another. Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4772
先回答 PM 要求先验证的那个问题:是功能洞,不只是日志误报
结论:是。
init()时定下的「没有 cache」结论会被冻结整个进程生命周期,后续限流计数一直用的是 better-auth 的进程内 memory store,即使 cache 随后注册上也不会切换。建议给本 issue 补security标签。证据链(全部来自代码,不是推测):
AuthPlugin.init()里那次getServiceAsync('cache')探测的结果写进authConfig.secondaryStorage,随后new AuthManager(authConfig)把它存进this.config。AuthManager的 better-auth 实例是懒创建的(getOrCreateAuth()→createAuthInstance()),但它读的是 init 时那份 config:...(this.config.secondaryStorage ? { storage: 'secondary-storage' } : {})。所以「懒创建」并没有让探测重来一次 —— 冻结的是结论,不是时机。init()早于CacheServicePlugin注册cache(issue 现场:早 21ms),所以标准serve组合下这个分支永远走 else。rateLimit.storage: 'memory'——node_modules/better-auth/dist/api/rate-limiter/index.mjs里那个模块级memoryMap,每个进程一份。也就是说:多节点部署的限额从来没有被全局强制过,攻击者轮换节点即可把限额乘以节点数;而日志还在告诉运维「去接 Redis」,接完 Redis 仍然是同一条 warn、同一个洞。ADR-0069 D2 声明的能力与运行时不一致(Prime Directive #10 的形态)。
红→绿也验证过:把
auth-plugin.tsstash 掉、只留新测试,新增的 5 条插件级测试有 4 条失败。改了什么
取 PM 裁定的修法 2(惰性解析),落点是 better-auth 的
rateLimit.customStorage。新增
packages/plugins/plugin-auth/src/rate-limit-storage.ts:createLazyCacheRateLimitStorage({ resolveCache, logger })—— 计数器被消费时才去解析cache服务。这一刻必然在kernel:ready之后,因此与任何插件启动顺序无关;解析到之后句柄缓存复用。customStorage会整体接管 better-auth 的存储选择,所以降级路径必须自己会数。incrementFixedWindow,与secondary-storage.ts的increment共用一份实现,两个计数入口不可能漂移。AuthManagerOptions新增rateLimitStorage(counters-only)。它刻意不放在rateLimit里面:bindAuthSettings在管理员调限流参数时会整个替换rateLimit对象,放进去等于「一改设置就悄悄退回不共享」,有测试钉住。一个必须让维护者知道的连带发现:为什么不用
secondaryStorageissue 的直觉修法是「把 cache 真的接成
secondaryStorage」。不能这么修,否则会在修一个安全洞的同时开另一个:better-auth 1.7.0-rc.2 的
internal-adapter.mjs:createSession:if (secondaryStorage && !storeInDb)—— 设了secondaryStorage就不写sys_session行;findSession:先读secondaryStorage.get(token),命中直接返回,完全不查库(即使打开storeSessionInDatabase双写,读路径依然以缓存为准)。而 ADR-0069 D4 的三个会话管控(
enforceSessionControls的空闲/绝对超时、enforceConcurrentCap的并发上限)全部靠写sys_session行来撤销会话,而且是 best-effort、异常吞掉。所以一旦 cache 被绑成secondaryStorage:查不到行 → 直接 return,三个管控静默失效;就算双写,写进库的撤销 better-auth 也读不到,缓存快照最长活到会话 TTL(默认 7 天)。外加sys_session空表会牵连sys_presence/sys_oauth_access_token/sys_oauth_refresh_token三处 lookup 外键和会话列表 UI。这个冲突一直没被发现,正是因为那次探测从来没成功过 —— 声明与运行时不一致把两个问题一起藏了起来。
本 PR 的处理:不替维护者决定会话该存哪。
secondaryStorage;它回归「宿主显式提供才生效」,行为与今天标准组合下的实际运行时行为完全一致(会话仍在sys_session)。cacheSecondaryStorage()改为从包根导出 + 就地写清代价,供知情的宿主自行选用,而不是留一个「顺序一变就静默废掉 D4」的地雷。sys_session行可写可撤(继续成立)。needs-user-decision),列了三个选项及各自代价,未自行决定。验收对照
auth-plugin.test.ts→does not warn during init when the cache has not registered yet;有 cache 时改打一条 inforate-limit-storage.test.ts→warns exactly once, at counting time, and says what is actually wrong+stops warning once the cache is there(有 cache 全程零 warn)rate-limit-storage.test.ts→picks the shared cache up on the first consume that follows registration;插件级同题counts in the cache registered AFTER initpackages/plugins/plugin-auth.changeset/与docs/adr/0069(状态行事实订正),未碰packages/objectql、service-storage、service-automation、plugin-approvals测试
红→绿证据(stash 掉
auth-plugin.ts后跑新测试):Tests 4 failed | 1 passed | 60 skipped。另跑通:
check:adr-anchors、check:durability-log-level、check:init-service-contract、check:service-providers。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Generated by Claude Code